przn 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +66 -0
- data/Rakefile +5 -5
- data/default_theme.yml +8 -0
- data/exe/przn +21 -4
- data/lib/przn/audience_link.rb +51 -0
- data/lib/przn/controller.rb +16 -2
- data/lib/przn/echoes_client.rb +83 -0
- data/lib/przn/image_util.rb +15 -3
- data/lib/przn/kitty_text.rb +23 -4
- data/lib/przn/parser.rb +90 -20
- data/lib/przn/{pdf_exporter.rb → prawn_pdf_exporter.rb} +26 -14
- data/lib/przn/presenter_renderer.rb +71 -0
- data/lib/przn/renderer.rb +180 -35
- data/lib/przn/screenshot_pdf_exporter.rb +18 -3
- data/lib/przn/slide.rb +25 -0
- data/lib/przn/theme.rb +32 -1
- data/lib/przn/version.rb +1 -1
- data/lib/przn.rb +77 -28
- data/sample/doge.jpg +0 -0
- data/sample/doge.png +0 -0
- data/sample/sample.md +24 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 926f4301117fde5642b907fbf96340d1e547af78086eb1fd47ed45fa16472e5e
|
|
4
|
+
data.tar.gz: 8cbbf68f1d784b6c6ed2ce7ba600c0a214dc8a688a785b5044701b9e8c587b39
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6f6a58582c7a21ccfab9750ee0ebc39525626af2233d503497e933a1d5b108bcf08b34c156115730c145e3c4dd7473b8d48633597d68b487bcc5702e29132183
|
|
7
|
+
data.tar.gz: 0a3bf226c4c4e6300d515cf50274c1ad266556c9a984df6090ed9dec1dbcc7cc3e878675131e2d26f0e71b1167046f1d5ea3e7f1b0f965101f942e5a728d9967
|
data/README.md
CHANGED
|
@@ -23,6 +23,23 @@ przn your_slides.md @42
|
|
|
23
23
|
|
|
24
24
|
Out-of-range numbers are clamped to the last slide, so `@9999` jumps to the end.
|
|
25
25
|
|
|
26
|
+
### Extended-display presenter mode
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
przn --present your_slides.md
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
On a setup with a secondary display (projector / external monitor) and running inside [Echoes](https://github.com/amatsuda/echoes), `--present` auto-spawns an **audience window** on the second display showing the clean current slide, while the laptop pane becomes the **presenter view**:
|
|
33
|
+
|
|
34
|
+
- Current slide rendered as normal
|
|
35
|
+
- Speaker notes (`{::note}` / `<note>` markup) shown in a side strip — stripped from the audience view
|
|
36
|
+
- Next slide's title hint
|
|
37
|
+
- Elapsed-time clock (or, when `rabbit:` is themed, the runner-bar visualization)
|
|
38
|
+
|
|
39
|
+
If only one display is attached or Echoes isn't the host terminal, `--present` falls back to today's mirror mode with a one-line warning on stderr.
|
|
40
|
+
|
|
41
|
+
Implementation: the two `przn` processes coordinate over a Unix socket. The presenter forwards every slide navigation as a `goto` message; the audience renders and otherwise stays silent. Notes are not transmitted to the audience side.
|
|
42
|
+
|
|
26
43
|
### PDF export
|
|
27
44
|
|
|
28
45
|
Two flavors:
|
|
@@ -58,6 +75,8 @@ przn --export prawn -o output.pdf your_slides.md
|
|
|
58
75
|
|
|
59
76
|
przn's Markdown format is compatible with [Rabbit](https://rabbit-shocker.org/)'s Markdown mode.
|
|
60
77
|
|
|
78
|
+
> **HTML-ish tag attributes** — every `<tag attr=value>` block below (`<bg>`, `<at>`, `<img>`, `<font>`) accepts three value forms: double-quoted `attr="value"`, single-quoted `attr='value'`, and unquoted `attr=value` (HTML5-ish — anything that isn't whitespace, `=`, `<`, `>`, a quote, or backtick). Self-closing tags need a space before `/>` when the last attribute is unquoted (`<img src=foo.png />`).
|
|
79
|
+
|
|
61
80
|
### Slide splitting
|
|
62
81
|
|
|
63
82
|
Slides are separated by `#` (h1) headings.
|
|
@@ -218,6 +237,49 @@ content...
|
|
|
218
237
|
|
|
219
238
|
The previous slide's background is cleared on every navigation, and on `przn` exit, so your shell isn't left tinted.
|
|
220
239
|
|
|
240
|
+
### Absolute-position text
|
|
241
|
+
|
|
242
|
+
Place text at an arbitrary `(column, row)` on the slide, escaping the normal top-down paragraph flow:
|
|
243
|
+
|
|
244
|
+
```markdown
|
|
245
|
+
# Layout test
|
|
246
|
+
|
|
247
|
+
<at x="10" y="5">top-left ish</at>
|
|
248
|
+
<at x="40" y="15"><size=3>BIG</size></at>
|
|
249
|
+
<at x="80" y="25"><color=red>warn</color></at>
|
|
250
|
+
<at x="50%" y="50%">dead center</at>
|
|
251
|
+
|
|
252
|
+
{::at x="10" y="20"}same thing, kramdown form{:/at}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
- `x` / `y` accept two forms:
|
|
256
|
+
- **Plain integer** — 1-based terminal cells, matching the cursor-position escape (`\e[y;xH`). `x="1" y="1"` is the very top-left of the slide pane.
|
|
257
|
+
- **Percent** (`x="50%"`, `y="100%"`) — resolves against the terminal's current width / height. Auto-adjusts when the pane is resized.
|
|
258
|
+
- Content is parsed inline, so all the usual styling works inside an `<at>` — `<size>`, `<color>`, `<font>`, `**bold**`, `*italic*`, etc.
|
|
259
|
+
- The block doesn't take up vertical space in the slide's layout — paragraphs around it render in their normal positions and the absolute placement layers on top. Useful for overlaying labels on a `<bg .../>` gradient or pinning annotations to specific cells.
|
|
260
|
+
- Out-of-range coordinates clamp into the visible area; missing / unparseable coordinates skip silently.
|
|
261
|
+
|
|
262
|
+
### Image
|
|
263
|
+
|
|
264
|
+
Embed an image with the standard markdown form, or the `<img>` XML form when you want to absolute-position it. Both produce identical output — `<img>` just opens the door to extra attributes like `x` / `y`.
|
|
265
|
+
|
|
266
|
+
```markdown
|
|
267
|
+
{:relative_height="70"}
|
|
268
|
+
<img src="doge.png" relative_height="70"/>
|
|
269
|
+
|
|
270
|
+
<img src="doge.png" x="5" y="3" relative_height="40"/>
|
|
271
|
+
<img src="doge.png" x="50%" y="50%" relative_height="40"/>
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
- `src` is required; `alt` and `title` are accepted and ignored at render time (kept for accessibility / future use).
|
|
275
|
+
- `relative_height="N"` caps the image at N % of the terminal height (default 70). Aspect ratio is preserved. `relative_width="N"` is the same for the horizontal dimension.
|
|
276
|
+
- `height="N%"` / `width="N%"` are short-form aliases for `relative_height` / `relative_width` (both forms — `<img>` and `![]{:...}` — accept the alias). An explicit `relative_*` on the same block wins; a non-`%` value (`height="40"`) is left alone since pixel units aren't supported.
|
|
277
|
+
- `x` / `y` (optional) anchor the image's top-left at an absolute cell. Same two forms as [`<at>`](#absolute-position-text):
|
|
278
|
+
- **Plain integer** — 1-based terminal cells.
|
|
279
|
+
- **Percent** — resolves against the terminal's current width / height.
|
|
280
|
+
- With `x` and `y` set, the image layers on top of the slide and contributes 0 to the layout flow — paragraphs around it render in their normal positions, exactly like `<at>`. Without `x` / `y`, the image stays horizontally centered and takes up its natural height in the flow.
|
|
281
|
+
- Rendering backend: Kitty Graphics Protocol on terminals that support it (PNG uploaded once and reused; JPG goes through `kitten icat`), Sixel as a fallback. Other terminals show nothing in place of the image.
|
|
282
|
+
|
|
221
283
|
### Comments
|
|
222
284
|
|
|
223
285
|
```markdown
|
|
@@ -285,6 +347,9 @@ background: # default slide background (Echoes OSC 7772)
|
|
|
285
347
|
to: # gradient endpoint
|
|
286
348
|
angle: # gradient angle in degrees
|
|
287
349
|
|
|
350
|
+
# rabbit: # opt into the 🐇 / 🐢 bottom progress indicator
|
|
351
|
+
# duration: "30m" # "1h30m", "1800s", or plain integer seconds; turtle hides when unset
|
|
352
|
+
|
|
288
353
|
colors:
|
|
289
354
|
code_bg: "313244"
|
|
290
355
|
dim: "6c7086"
|
|
@@ -298,6 +363,7 @@ Notes:
|
|
|
298
363
|
- **`font.family`** — applied to body text (terminal: via OSC 66 `f=`, requires Echoes; PDF: registered via fontconfig). Inline `<font face="...">` runs override it per-segment.
|
|
299
364
|
- **`title`** — h1 typography. Each attribute is independent from `font`: `title.family` does **not** inherit `font.family`, `title.color` does **not** inherit `font.color`. `title.size` defaults to x-large (OSC 66 `s=4`). When `title.family` is proportional, every h1 OSC 66 sequence is emitted with `h=2` so a terminal that honors centered horizontal alignment ([Echoes](https://github.com/amatsuda/echoes)) keeps the title visually centered against its reserved cell block. h2–h6 stay body text.
|
|
300
365
|
- **`background`** — the deck-wide default background. A per-slide `<bg .../>` directive overrides it for that slide. The Prawn fallback paints the PDF page in `background.color` when set; otherwise it leaves the page Prawn's default (white).
|
|
366
|
+
- **`rabbit`** — opt-in Rabbit-style bottom-row progress indicator. With the key absent, przn shows the simple `N / M` counter at the bottom-right. With the key present, the bottom row becomes: current slide # at the very left, total at the very right, 🐇 running between them tracking slide progress. Set `rabbit.duration` to also show 🐢 tracking elapsed time against the goal; without a duration the turtle stays hidden. Inside [Echoes](https://github.com/amatsuda/echoes) the emojis are emitted via OSC 7772 `;multicell` with `flip=h` so they face rightward; outside Echoes they fall back to standard OSC 66 and render unflipped (left-facing).
|
|
301
367
|
|
|
302
368
|
## License
|
|
303
369
|
|
data/Rakefile
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
3
|
+
require 'bundler/gem_tasks'
|
|
4
|
+
require 'rake/testtask'
|
|
5
5
|
|
|
6
6
|
Rake::TestTask.new(:test) do |t|
|
|
7
|
-
t.libs <<
|
|
8
|
-
t.libs <<
|
|
9
|
-
t.test_files = FileList[
|
|
7
|
+
t.libs << 'test'
|
|
8
|
+
t.libs << 'lib'
|
|
9
|
+
t.test_files = FileList['test/**/*_test.rb']
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
task default: :test
|
data/default_theme.yml
CHANGED
|
@@ -33,3 +33,11 @@ colors:
|
|
|
33
33
|
code_bg: "313244"
|
|
34
34
|
dim: "6c7086"
|
|
35
35
|
inline_code: "a6e3a1"
|
|
36
|
+
|
|
37
|
+
# Bottom-of-screen progress indicator (a nod to the Rabbit presentation tool).
|
|
38
|
+
# Without this key, przn shows a simple " N / M " counter at the bottom-right.
|
|
39
|
+
# To opt in: uncomment the block. The 🐇 rabbit anchors slide progress between
|
|
40
|
+
# the left (current) and right (total) numbers. Set `duration:` to enable the
|
|
41
|
+
# 🐢 turtle tracking elapsed time against the goal.
|
|
42
|
+
# rabbit:
|
|
43
|
+
# duration: # "30m", "1h30m", "1800s", or plain integer seconds
|
data/exe/przn
CHANGED
|
@@ -7,7 +7,7 @@ require 'optparse'
|
|
|
7
7
|
|
|
8
8
|
options = {}
|
|
9
9
|
OptionParser.new do |opts|
|
|
10
|
-
opts.banner =
|
|
10
|
+
opts.banner = 'Usage: przn [options] <presentation.md>'
|
|
11
11
|
opts.on('--export [FORMAT]', 'Export to a format (pdf | prawn; default: pdf)') { |v|
|
|
12
12
|
if v && v.end_with?('.md')
|
|
13
13
|
ARGV.unshift(v)
|
|
@@ -19,12 +19,15 @@ OptionParser.new do |opts|
|
|
|
19
19
|
opts.on('-o', '--output FILE', 'Output file path for export') { |v| options[:output] = v }
|
|
20
20
|
opts.on('--theme FILE', 'Theme file (YAML)') { |v| options[:theme] = v }
|
|
21
21
|
opts.on('--generate-theme', 'Generate theme.yml in current directory') { options[:generate_theme] = true }
|
|
22
|
+
opts.on('--present', 'Open the deck in presenter mode (auto-spawns an audience window on the secondary display)') { options[:present] = true }
|
|
23
|
+
opts.on('--audience', 'Run as the audience receiver (spawned by --present; expects --socket)') { options[:audience] = true }
|
|
24
|
+
opts.on('--socket PATH', 'Unix socket path used by --present/--audience to coordinate') { |v| options[:socket] = v }
|
|
22
25
|
end.parse!
|
|
23
26
|
|
|
24
27
|
if options[:generate_theme]
|
|
25
28
|
content = File.read(Przn::Theme::DEFAULT_PATH).sub(/\A(#[^\n]*\n)+\n/, '')
|
|
26
29
|
File.write('theme.yml', content)
|
|
27
|
-
puts
|
|
30
|
+
puts 'Generated: theme.yml'
|
|
28
31
|
exit
|
|
29
32
|
end
|
|
30
33
|
|
|
@@ -38,7 +41,7 @@ end
|
|
|
38
41
|
|
|
39
42
|
file = ARGV[0]
|
|
40
43
|
unless file
|
|
41
|
-
$stderr.puts
|
|
44
|
+
$stderr.puts 'Usage: przn [options] <presentation.md> [@N]'
|
|
42
45
|
exit 1
|
|
43
46
|
end
|
|
44
47
|
|
|
@@ -50,11 +53,25 @@ end
|
|
|
50
53
|
|
|
51
54
|
case options[:export]
|
|
52
55
|
when 'pdf'
|
|
56
|
+
require_relative '../lib/przn/screenshot_pdf_exporter'
|
|
57
|
+
|
|
53
58
|
output = options[:output] || File.basename(file, File.extname(file)) + '.pdf'
|
|
54
59
|
Przn.export_pdf(file, output, theme: theme)
|
|
55
60
|
when 'prawn'
|
|
61
|
+
require_relative '../lib/przn/prawn_pdf_exporter'
|
|
62
|
+
|
|
56
63
|
output = options[:output] || File.basename(file, File.extname(file)) + '.pdf'
|
|
57
64
|
Przn.export_pdf_prawn(file, output, theme: theme)
|
|
58
65
|
else
|
|
59
|
-
|
|
66
|
+
if options[:audience]
|
|
67
|
+
unless options[:socket]
|
|
68
|
+
$stderr.puts 'przn: --audience requires --socket PATH'
|
|
69
|
+
exit 1
|
|
70
|
+
end
|
|
71
|
+
Przn.audience(file, socket: options[:socket], theme: theme)
|
|
72
|
+
elsif options[:present]
|
|
73
|
+
Przn.present(file, theme: theme, theme_path: options[:theme]).run
|
|
74
|
+
else
|
|
75
|
+
Przn.start(file, theme: theme, start_at: start_at).run
|
|
76
|
+
end
|
|
60
77
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'socket'
|
|
5
|
+
|
|
6
|
+
module Przn
|
|
7
|
+
# Tiny line-delimited JSON protocol over a Unix-domain socket, joining the
|
|
8
|
+
# presenter and audience `przn` processes in extended-display mode.
|
|
9
|
+
#
|
|
10
|
+
# Messages currently exchanged:
|
|
11
|
+
# {"type": "ready"} audience -> presenter
|
|
12
|
+
# {"type": "goto", "index": N} presenter -> audience
|
|
13
|
+
# {"type": "quit"} presenter -> audience
|
|
14
|
+
module AudienceLink
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Audience-side: open a UNIXServer at `path`, wait for the presenter to
|
|
18
|
+
# connect, then yield each decoded message until EOF or {"type":"quit"}.
|
|
19
|
+
# The socket file is unlinked on exit.
|
|
20
|
+
def serve(path)
|
|
21
|
+
File.unlink(path) if File.exist?(path)
|
|
22
|
+
server = UNIXServer.new(path)
|
|
23
|
+
client = server.accept
|
|
24
|
+
send(client, {type: "ready"})
|
|
25
|
+
while (line = client.gets)
|
|
26
|
+
msg = JSON.parse(line.chomp, symbolize_names: true)
|
|
27
|
+
break if msg[:type] == "quit"
|
|
28
|
+
yield msg
|
|
29
|
+
end
|
|
30
|
+
rescue Errno::EPIPE, EOFError, IOError
|
|
31
|
+
# Presenter went away; let the caller exit cleanly.
|
|
32
|
+
ensure
|
|
33
|
+
client&.close
|
|
34
|
+
server&.close
|
|
35
|
+
File.unlink(path) if path && File.exist?(path)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Presenter-side: connect to an audience socket at `path` and return a
|
|
39
|
+
# client object that responds to `#send` and `#close`. Caller drives the
|
|
40
|
+
# protocol from the controller.
|
|
41
|
+
def connect(path)
|
|
42
|
+
UNIXSocket.new(path)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def send(io, msg)
|
|
46
|
+
io.puts(JSON.generate(msg))
|
|
47
|
+
rescue Errno::EPIPE, IOError
|
|
48
|
+
# Other side hung up — caller decides whether to keep going.
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/przn/controller.rb
CHANGED
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module Przn
|
|
4
4
|
class Controller
|
|
5
|
-
def initialize(presentation, terminal, renderer)
|
|
5
|
+
def initialize(presentation, terminal, renderer, audience_link: nil)
|
|
6
6
|
@presentation = presentation
|
|
7
7
|
@terminal = terminal
|
|
8
8
|
@renderer = renderer
|
|
9
|
+
@audience_link = audience_link
|
|
9
10
|
@preload_gen = 0
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
def run
|
|
14
|
+
@started_at = Time.now
|
|
13
15
|
@terminal.enter_alt_screen
|
|
14
16
|
@terminal.hide_cursor
|
|
15
17
|
render_current
|
|
@@ -37,7 +39,12 @@ module Przn
|
|
|
37
39
|
ensure
|
|
38
40
|
@preload_gen += 1
|
|
39
41
|
@preload_thread&.join
|
|
42
|
+
if @audience_link
|
|
43
|
+
AudienceLink.send(@audience_link, type: "quit")
|
|
44
|
+
@audience_link.close
|
|
45
|
+
end
|
|
40
46
|
@terminal.write "\e]7772;bg-clear\a"
|
|
47
|
+
@terminal.write ImageUtil.kitty_clear_all if ImageUtil.kitty_terminal?
|
|
41
48
|
@terminal.show_cursor
|
|
42
49
|
@terminal.leave_alt_screen
|
|
43
50
|
end
|
|
@@ -48,8 +55,15 @@ module Przn
|
|
|
48
55
|
@renderer.render(
|
|
49
56
|
@presentation.current_slide,
|
|
50
57
|
current: @presentation.current,
|
|
51
|
-
total: @presentation.total
|
|
58
|
+
total: @presentation.total,
|
|
59
|
+
started_at: @started_at
|
|
52
60
|
)
|
|
61
|
+
if @audience_link
|
|
62
|
+
AudienceLink.send(@audience_link,
|
|
63
|
+
type: "goto",
|
|
64
|
+
index: @presentation.current,
|
|
65
|
+
started_at: @started_at.to_f)
|
|
66
|
+
end
|
|
53
67
|
schedule_preload
|
|
54
68
|
end
|
|
55
69
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'io/console'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'timeout'
|
|
6
|
+
|
|
7
|
+
module Przn
|
|
8
|
+
# Thin wrappers around Echoes-specific OSC 7772 commands the presenter uses
|
|
9
|
+
# to set up extended-display mode. Other terminals ignore OSC 7772, so each
|
|
10
|
+
# method silently fails (returns nil / false) when not running inside Echoes.
|
|
11
|
+
module EchoesClient
|
|
12
|
+
OSC = "\e]7772"
|
|
13
|
+
BEL = "\a"
|
|
14
|
+
REPLY_TIMEOUT_S = 0.5
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Ask Echoes how many displays are attached and a tiny descriptor for each.
|
|
19
|
+
# Returns an Array of Hashes like [{index: 0, w: 1920, h: 1080}, ...] or
|
|
20
|
+
# nil when no reply arrives within the timeout (non-Echoes terminal, or
|
|
21
|
+
# an Echoes that doesn't speak this command yet).
|
|
22
|
+
def display_info(io_in: $stdin, io_out: $stdout)
|
|
23
|
+
io_out.write("#{OSC};display-info#{BEL}")
|
|
24
|
+
io_out.flush if io_out.respond_to?(:flush)
|
|
25
|
+
reply = read_osc_reply(io_in)
|
|
26
|
+
return nil unless reply
|
|
27
|
+
JSON.parse(reply, symbolize_names: true)
|
|
28
|
+
rescue JSON::ParserError
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Open a new Echoes window on the given display, running `argv` (an
|
|
33
|
+
# Array of strings — argv[0] is the executable). `fullscreen:` is a hint.
|
|
34
|
+
# Returns true if the request was emitted; nothing in the protocol confirms
|
|
35
|
+
# success synchronously.
|
|
36
|
+
def open_window(display:, argv:, fullscreen: true, io_out: $stdout)
|
|
37
|
+
# `pack('m0')` is strict (no-newline) base64 — same as
|
|
38
|
+
# Base64.strict_encode64 but without pulling in the base64 stdlib,
|
|
39
|
+
# which is no longer a default gem in Ruby 3.4+.
|
|
40
|
+
payload = [JSON.generate(argv)].pack('m0')
|
|
41
|
+
args = "display=#{display}:program=#{payload}:fullscreen=#{fullscreen ? 'yes' : 'no'}"
|
|
42
|
+
io_out.write("#{OSC};open-window;#{args}#{BEL}")
|
|
43
|
+
io_out.flush if io_out.respond_to?(:flush)
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Read an OSC reply up to ST or BEL. Returns the payload string or nil on
|
|
48
|
+
# timeout. Echoes replies follow the same `\e]7772;...\a` shape it accepts.
|
|
49
|
+
#
|
|
50
|
+
# Stdin defaults to canonical (line-buffered) mode in a shell context, so
|
|
51
|
+
# `getc` would block waiting for a newline that an OSC reply never sends.
|
|
52
|
+
# Put the input in raw mode for the duration of the read; IO#raw saves and
|
|
53
|
+
# restores termios automatically.
|
|
54
|
+
def read_osc_reply(io_in)
|
|
55
|
+
if io_in.respond_to?(:raw) && io_in.respond_to?(:tty?) && io_in.tty?
|
|
56
|
+
io_in.raw { read_osc_reply_inner(io_in) }
|
|
57
|
+
else
|
|
58
|
+
read_osc_reply_inner(io_in)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def read_osc_reply_inner(io_in)
|
|
63
|
+
Timeout.timeout(REPLY_TIMEOUT_S) do
|
|
64
|
+
buf = +""
|
|
65
|
+
loop do
|
|
66
|
+
c = io_in.getc
|
|
67
|
+
return nil if c.nil?
|
|
68
|
+
break if c == BEL
|
|
69
|
+
if c == "\e"
|
|
70
|
+
nxt = io_in.getc
|
|
71
|
+
break if nxt == "\\"
|
|
72
|
+
buf << c << nxt
|
|
73
|
+
else
|
|
74
|
+
buf << c
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
buf.sub(/\A\e?\]?7772;[\w-]+;/, '')
|
|
78
|
+
end
|
|
79
|
+
rescue Timeout::Error
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/przn/image_util.rb
CHANGED
|
@@ -42,14 +42,14 @@ module Przn
|
|
|
42
42
|
# GIF
|
|
43
43
|
f.seek(0)
|
|
44
44
|
sig = f.read(6)
|
|
45
|
-
if sig&.start_with?(
|
|
45
|
+
if sig&.start_with?('GIF8')
|
|
46
46
|
w = f.read(2)&.unpack1('v')
|
|
47
47
|
h = f.read(2)&.unpack1('v')
|
|
48
48
|
return [w, h] if w && h
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
nil
|
|
52
|
-
rescue
|
|
52
|
+
rescue StandardError
|
|
53
53
|
nil
|
|
54
54
|
end
|
|
55
55
|
|
|
@@ -62,7 +62,9 @@ module Przn
|
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def kitty_terminal?
|
|
65
|
-
ENV['TERM'] == 'xterm-kitty' ||
|
|
65
|
+
ENV['TERM'] == 'xterm-kitty' ||
|
|
66
|
+
ENV['TERM_PROGRAM'] == 'kitty' ||
|
|
67
|
+
ENV['TERM_PROGRAM'] == 'Echoes'
|
|
66
68
|
end
|
|
67
69
|
|
|
68
70
|
PNG_MAGIC = "\x89PNG\r\n\x1a\n".b.freeze
|
|
@@ -89,6 +91,16 @@ module Przn
|
|
|
89
91
|
"\e_Ga=p,i=#{image_id},c=#{cols},r=#{rows},q=2\e\\"
|
|
90
92
|
end
|
|
91
93
|
|
|
94
|
+
# Kitty Graphics Protocol: delete every placement and free the
|
|
95
|
+
# stored image data. Used on quit so previously-rendered images
|
|
96
|
+
# don't leak through onto the user's restored shell screen
|
|
97
|
+
# (placements aren't tied to the alt-screen buffer in most
|
|
98
|
+
# kitty-protocol implementations, so leaving the alt screen
|
|
99
|
+
# alone isn't enough to hide them). `q=2` suppresses the OK reply.
|
|
100
|
+
def kitty_clear_all
|
|
101
|
+
"\e_Ga=d,d=A,q=2\e\\"
|
|
102
|
+
end
|
|
103
|
+
|
|
92
104
|
# Sixel via img2sixel
|
|
93
105
|
def sixel_available?
|
|
94
106
|
@sixel_available = system('command -v img2sixel > /dev/null 2>&1') if @sixel_available.nil?
|
data/lib/przn/kitty_text.rb
CHANGED
|
@@ -5,19 +5,38 @@ module Przn
|
|
|
5
5
|
HEADING_SCALES = {
|
|
6
6
|
1 => 4,
|
|
7
7
|
2 => 3,
|
|
8
|
-
3 => 2
|
|
8
|
+
3 => 2
|
|
9
9
|
}.freeze
|
|
10
10
|
|
|
11
11
|
module_function
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
# Emit sized multicell text. The `s/w/n/d/v/h` params are standard kitty
|
|
14
|
+
# OSC 66 (portable). The `f=` (font family) and `flip=` params are
|
|
15
|
+
# Echoes-only extensions — when one of them is set AND we're running
|
|
16
|
+
# inside Echoes, they ride on the private OSC 7772 ;multicell frame so
|
|
17
|
+
# that strict kitty terminals never see unknown params on OSC 66.
|
|
18
|
+
# Otherwise the extensions are silently dropped and we emit plain OSC
|
|
19
|
+
# 66, which renders without the flip / custom font on any kitty-
|
|
20
|
+
# compatible terminal (better than emitting an OSC 7772 frame the
|
|
21
|
+
# terminal would ignore entirely).
|
|
22
|
+
def sized(text, s:, h: nil, v: nil, n: nil, d: nil, f: nil, flip: nil)
|
|
14
23
|
params = +"s=#{s}"
|
|
15
24
|
params << ":n=#{n}" if n
|
|
16
25
|
params << ":d=#{d}" if d
|
|
17
26
|
params << ":h=#{h}" if h
|
|
18
27
|
params << ":v=#{v}" if v
|
|
19
|
-
|
|
20
|
-
|
|
28
|
+
|
|
29
|
+
if (f || flip) && echoes?
|
|
30
|
+
params << ":f=#{f}" if f
|
|
31
|
+
params << ":flip=#{flip}" if flip
|
|
32
|
+
"\e]7772;multicell;#{params};#{text}\a"
|
|
33
|
+
else
|
|
34
|
+
"\e]66;#{params};#{text}\a"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def echoes?
|
|
39
|
+
ENV['TERM_PROGRAM'] == 'Echoes'
|
|
21
40
|
end
|
|
22
41
|
|
|
23
42
|
def heading(text, level:)
|